// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

/// Generates the file `api.txt`, which describes a package's public API.
library;

import 'dart:collection';

import 'package:analyzer/dart/analysis/analysis_context.dart';
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/constant/value.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/nullability_suffix.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer_utilities/tools.dart';
import 'package:collection/collection.dart';
import 'package:path/path.dart';

/// Computes a list of all targets generated by this code generator for a given
/// package.
List<GeneratedContent> allTargetsForPackage(String pkgName) => [
  GeneratedFile('$pkgName/api.txt', (pkgRoot) async {
    var provider = PhysicalResourceProvider.INSTANCE;
    var collection = AnalysisContextCollection(
      includedPaths: [join(pkgRoot, pkgName)],
      resourceProvider: provider,
    );
    // Use `.single` to make sure that `collection` just contains a single
    // context. This ensures that `publicApi.build` will see all the files in
    // the package.
    var context = collection.contexts.single;
    var publicApi = ApiDescription(pkgName);
    var stringBuffer = StringBuffer();
    _printNodes(stringBuffer, await publicApi.build(context));
    return stringBuffer.toString();
  }),
];

/// Outputs the contents of [nodes] to [sink], prepending [prefix] to every
/// line.
void _printNodes<SortKey extends Comparable<SortKey>>(
  StringSink sink,
  List<(SortKey, Node)> nodes, {
  String prefix = '',
}) {
  for (var entry in nodes.sortedBy((n) => n.$1)) {
    var node = entry.$2;
    sink.writeln('$prefix${node.text.join()}');
    node.printChildren(sink, prefix: '$prefix  ');
  }
}

/// Data structure keeping track of a package's API while walking it to produce
/// `api.txt`.
class ApiDescription {
  final String _pkgName;

  /// Top level elements that have already had their child elements dumped.
  ///
  /// If an element is seen again in a different library, it will be followed
  /// with `(see above)` (rather than having its child elements dumped twice).
  final _dumpedTopLevelElements = <Element>{};

  /// Top level elements that have been referenced so far and haven't yet been
  /// processed by [build].
  ///
  /// This is used to ensure that all elements referred to by the public API
  /// (e.g., by being mentioned in the type of an API element) also show up in
  /// the output.
  final _potentiallyDanglingReferences = Queue<Element>();

  final _uniqueNamer = UniqueNamer();

  /// Cache of values returned by [_getOrComputeImmediateSubinterfaceMap], to
  /// avoid unnecessary recomputation.
  final _immediateSubinterfaceCache =
      <LibraryElement, Map<ClassElement, Set<InterfaceElement>>>{};

  ApiDescription(String pkgName) : _pkgName = pkgName;

  /// Builds a list of [Node] objects representing all the libraries that are
  /// relevant to the package's public API.
  ///
  /// This includes libraries that are in the package's public API as well as
  /// libraries that are referenced by the package's public API (either by being
  /// re-exported as part of the package's public API, or by being used as part
  /// of the type of something in the public API).
  ///
  /// Each library node is pared with a [UriSortKey] indicating the order in
  /// which the nodes should be output.
  Future<List<(UriSortKey, Node)>> build(AnalysisContext context) async {
    var nodes = <Uri, Node<MemberSortKey>>{};
    for (var file in context.contextRoot.analyzedFiles().sorted()) {
      if (!file.endsWith('.dart')) continue;
      var fileResult = context.currentSession.getFile(file) as FileResult;
      var uri = fileResult.uri;
      if (fileResult.isLibrary && uri.isInPublicLibOf(_pkgName)) {
        var resolvedLibraryResult =
            (await context.currentSession.getResolvedLibrary(file))
                as ResolvedLibraryResult;
        var node = nodes[uri] = Node<MemberSortKey>();
        _dumpLibrary(resolvedLibraryResult.element, node);
      }
    }
    // Then dump anything referenced by public libraries.
    while (_potentiallyDanglingReferences.isNotEmpty) {
      var element = _potentiallyDanglingReferences.removeFirst();
      if (!_dumpedTopLevelElements.add(element)) continue;
      var containingLibraryUri = element.library!.uri;
      var childNode = Node<MemberSortKey>()
        ..text.add(_uniqueNamer.name(element));
      _dumpElement(element, childNode);
      (nodes[containingLibraryUri] ??= Node<MemberSortKey>()
            ..text.add('$containingLibraryUri:'))
          .childNodes
          .add((MemberSortKey(element), childNode));
    }
    return [
      for (var entry in nodes.entries)
        (UriSortKey(entry.key, _pkgName), entry.value),
    ];
  }

  /// Creates a list of objects which, when their string representations are
  /// concatenated, describes [type].
  ///
  /// The reason we use this method rather than [DartType.toString] is to make
  /// sure that (a) every element mentioned by the type is added to
  /// [_potentiallyDanglingReferences], and (b) if an ambiguous name is used,
  /// the ambiguity will be taken care of by [_uniqueNamer].
  List<Object?> _describeType(DartType type) {
    var suffix = switch (type.nullabilitySuffix) {
      NullabilitySuffix.none => '',
      NullabilitySuffix.star => '*',
      NullabilitySuffix.question => '?',
    };
    switch (type) {
      case DynamicType():
        return ['dynamic'];
      case FunctionType(
        :var returnType,
        :var typeParameters,
        :var formalParameters,
      ):
        var params = <List<Object?>>[];
        var optionalParams = <List<Object?>>[];
        var namedParams = <String, List<Object?>>{};
        for (var formalParameter in formalParameters) {
          if (formalParameter.isNamed) {
            namedParams[formalParameter.name!] = [
              if (formalParameter.isDeprecated) 'deprecated ',
              if (formalParameter.isRequired) 'required ',
              ..._describeType(formalParameter.type),
            ];
          } else if (formalParameter.isOptional) {
            optionalParams.add([
              if (formalParameter.isDeprecated) 'deprecated ',
              ..._describeType(formalParameter.type),
            ]);
          } else {
            params.add([
              if (formalParameter.isDeprecated) 'deprecated ',
              ..._describeType(formalParameter.type),
            ]);
          }
        }
        if (optionalParams.isNotEmpty) {
          params.add(optionalParams.separate(prefix: '[', suffix: ']'));
        }
        if (namedParams.isNotEmpty) {
          params.add(
            namedParams.entries
                .sortedBy((e) => e.key)
                .map((e) => [...e.value, ' ${e.key}'])
                .separate(prefix: '{', suffix: '}'),
          );
        }
        return <Object?>[
          ..._describeType(returnType),
          ' Function',
          if (typeParameters.isNotEmpty)
            ...typeParameters
                .map(_describeTypeParameter)
                .separate(prefix: '<', suffix: '>'),
          '(',
          ...params.separate(),
          ')',
          suffix,
        ];
      case InterfaceType(:var element, :var typeArguments):
        _potentiallyDanglingReferences.addLast(element);
        return [
          _uniqueNamer.name(element),
          if (typeArguments.isNotEmpty)
            ...typeArguments
                .map(_describeType)
                .separate(prefix: '<', suffix: '>'),
          suffix,
        ];
      case RecordType(:var positionalFields, :var namedFields):
        if (positionalFields.length == 1 && namedFields.isEmpty) {
          return [
            '(',
            ..._describeType(positionalFields[0].type),
            ',)',
            suffix,
          ];
        }
        return [
          ...[
            for (var positionalField in positionalFields)
              _describeType(positionalField.type),
            if (namedFields.isNotEmpty)
              namedFields
                  .sortedBy((f) => f.name)
                  .map((f) => [..._describeType(f.type), ' ', f.name])
                  .separate(prefix: '{', suffix: '}'),
          ].separate(prefix: '(', suffix: ')'),
          suffix,
        ];
      case TypeParameterType(:var element):
        return [element.name!, suffix];
      case VoidType():
        return ['void'];
      case dynamic(:var runtimeType):
        throw UnimplementedError('Unexpected type: $runtimeType');
    }
  }

  /// Creates a list of objects which, when their string representations are
  /// concatenated, describes [typeParameter].
  List<Object?> _describeTypeParameter(TypeParameterElement typeParameter) {
    return [
      typeParameter.name!,
      if (typeParameter.bound case var bound?) ...[
        ' extends ',
        ..._describeType(bound),
      ],
    ];
  }

  /// Appends information to [node] describing [element].
  void _dumpElement(Element element, Node<MemberSortKey> node) {
    var enclosingElement = element.enclosingElement;
    if (enclosingElement is LibraryElement &&
        !element.isInPublicApiOf(_pkgName)) {
      if (!enclosingElement.uri.isIn(_pkgName)) {
        node.text.add(' (referenced)');
      } else {
        node.text.add(' (non-public)');
      }
      return;
    }
    var parentheticals = <List<Object?>>[];
    switch (element) {
      case TypeAliasElement(:var aliasedType, :var typeParameters):
        List<Object?> description = ['type alias'];
        if (typeParameters.isNotEmpty) {
          description.addAll(
            typeParameters
                .map(_describeTypeParameter)
                .separate(prefix: '<', suffix: '>'),
          );
        }
        description.addAll([' for ', ..._describeType(aliasedType)]);
        parentheticals.add(description);
      case InstanceElement():
        switch (element) {
          case InterfaceElement(
            :var typeParameters,
            :var supertype,
            :var interfaces,
          ):
            List<Object?> instanceDescription = [
              switch (element) {
                ClassElement() => 'class',
                EnumElement() => 'enum',
                MixinElement() => 'mixin',
                dynamic(:var runtimeType) => 'TODO: $runtimeType',
              },
            ];
            if (typeParameters.isNotEmpty) {
              instanceDescription.addAll(
                typeParameters
                    .map(_describeTypeParameter)
                    .separate(prefix: '<', suffix: '>'),
              );
            }
            if (element is! EnumElement && supertype != null) {
              instanceDescription.addAll([
                ' extends ',
                ..._describeType(supertype),
              ]);
            }
            if (element is MixinElement &&
                element.superclassConstraints.isNotEmpty) {
              instanceDescription.addAll(
                element.superclassConstraints
                    .map(_describeType)
                    .separate(prefix: ' on '),
              );
            }
            if (interfaces.isNotEmpty) {
              instanceDescription.addAll(
                interfaces.map(_describeType).separate(prefix: ' implements '),
              );
            }
            parentheticals.add(instanceDescription);
            if (element is ClassElement && element.isSealed) {
              var parenthetical = <Object>['sealed'];
              parentheticals.add(parenthetical);
              if (_getOrComputeImmediateSubinterfaceMap(
                    element.library,
                  )[element]
                  case var subinterfaces?) {
                parenthetical.add(' (immediate subtypes: ');
                // Note: it's tempting to just do
                // `subinterfaces.map(_uniqueNamer.name).join(', ')`, but that
                // won't work, because the names returned by
                // `UniqueName.toString()` aren't finalized until we've visited
                // the entire API and seen if there are class names that need to
                // be disambiguated. So we accumulate the `UniqueName` objects
                // into the `parenthetical` list and rely on `printNodes`
                // converting everything to a string when the final API
                // description is being output.
                var commaNeeded = false;
                for (var subinterface in subinterfaces) {
                  if (commaNeeded) {
                    parenthetical.add(', ');
                  } else {
                    commaNeeded = true;
                  }
                  parenthetical.add(_uniqueNamer.name(subinterface));
                }
                parenthetical.add(')');
              }
            }
          case ExtensionElement(:var extendedType):
            parentheticals.add([
              'extension on ',
              ..._describeType(extendedType),
            ]);
          case dynamic(:var runtimeType):
            throw UnimplementedError('Unexpected element: $runtimeType');
        }
        for (var member in element.children.sortedBy((m) => m.name ?? '')) {
          if (member.name case var name? when name.startsWith('_')) {
            // Ignore private members
            continue;
          }
          if (member is FieldElement) {
            // Ignore fields; we care about the getters and setters they induce.
            continue;
          }
          if (member is ConstructorElement &&
              element is ClassElement &&
              element.isAbstract &&
              (element.isFinal || element.isInterface || element.isSealed)) {
            // The class can't be constructed from outside of the library that
            // declares it, so its constructors aren't part of the public API.
            continue;
          }
          if (member is ConstructorElement && element is EnumElement) {
            // Enum constructors can't be called from outside the enum itself,
            // so they aren't part of the public API.
            continue;
          }
          var childNode = Node<MemberSortKey>();
          childNode.text.add(member.apiName);
          _dumpElement(member, childNode);
          node.childNodes.add((MemberSortKey(member), childNode));
        }
      case TopLevelFunctionElement(:var type):
        parentheticals.add(['function: ', ..._describeType(type)]);
      case ExecutableElement(:var isStatic):
        String maybeStatic = isStatic ? 'static ' : '';
        switch (element) {
          case GetterElement(:var type):
            parentheticals.add([
              '${maybeStatic}getter: ',
              ..._describeType(type.returnType),
            ]);
          case SetterElement(:var type):
            parentheticals.add([
              '${maybeStatic}setter: ',
              ..._describeType(type.formalParameters.single.type),
            ]);
          case MethodElement(:var type):
            parentheticals.add([
              '${maybeStatic}method: ',
              ..._describeType(type),
            ]);
          case ConstructorElement(:var type):
            parentheticals.add(['constructor: ', ..._describeType(type)]);
          case dynamic(:var runtimeType):
            throw UnimplementedError('Unexpected element: $runtimeType');
        }
      case dynamic(:var runtimeType):
        throw UnimplementedError('Unexpected element: $runtimeType');
    }

    // For synthetic elements such as getters/setters induced by top level
    // variables and fields, annotations can be found on the corresponding
    // non-synthetic element.
    var nonSyntheticElement = element.nonSynthetic;
    if (nonSyntheticElement.metadata.hasDeprecated) {
      parentheticals.add(['deprecated']);
    }
    if (nonSyntheticElement.metadata.hasExperimental) {
      parentheticals.add(['experimental']);
    }

    if (parentheticals.isNotEmpty) {
      node.text.addAll(parentheticals.separate(prefix: ' (', suffix: ')'));
    }
    if (node.childNodes.isNotEmpty) {
      node.text.add(':');
    }
  }

  /// Appends information to [node] describing [element].
  void _dumpLibrary(LibraryElement library, Node<MemberSortKey> node) {
    var uri = library.uri;
    node.text.addAll([uri, ':']);
    var definedNames = library.exportNamespace.definedNames2;
    for (var key in definedNames.keys.sorted()) {
      var element = definedNames[key]!;
      var childNode = Node<MemberSortKey>()
        ..text.add(_uniqueNamer.name(element));
      if (!_dumpedTopLevelElements.add(element)) {
        childNode.text.add(' (see above)');
      } else {
        _dumpElement(element, childNode);
      }
      node.childNodes.add((MemberSortKey(element), childNode));
    }
  }

  /// Returns a map from each sealed class in [library] to the set of its
  /// immediate sub-interfaces.
  ///
  /// If this method has been called before with the same [library], a cached
  /// map is returned from [_immediateSubinterfaceCache]. Otherwise a fresh map
  /// is computed.
  Map<ClassElement, Set<InterfaceElement>>
  _getOrComputeImmediateSubinterfaceMap(LibraryElement library) {
    if (_immediateSubinterfaceCache[library] case var m?) return m;
    var result = <ClassElement, Set<InterfaceElement>>{};
    for (var interface in [
      ...library.classes,
      ...library.mixins,
      ...library.enums,
      ...library.extensionTypes,
    ]..sortBy((e) => e.name!)) {
      for (var superinterface in [
        interface.supertype,
        ...interface.interfaces,
        ...interface.mixins,
        if (interface is MixinElement) ...interface.superclassConstraints,
      ]) {
        if (superinterface == null) continue;
        var superinterfaceElement = superinterface.element;
        if (superinterfaceElement is ClassElement &&
            superinterfaceElement.isSealed) {
          (result[superinterfaceElement] ??= {}).add(interface);
        }
      }
    }
    _immediateSubinterfaceCache[library] = result;
    return result;
  }
}

/// Element categorization used by [MemberSortKey].
enum MemberCategory {
  constructor,
  propertyAccessor,
  topLevelFunctionOrMethod,
  interface,
  extension,
  typeAlias,
}

/// Sort key used to sort elements in the output.
class MemberSortKey implements Comparable<MemberSortKey> {
  final bool _isInstanceMember;
  final MemberCategory _category;
  final String _name;

  MemberSortKey(Element element)
    : _isInstanceMember = _computeIsInstanceMember(element),
      _category = _computeCategory(element),
      _name = element.name!;

  @override
  int compareTo(MemberSortKey other) {
    if ((_isInstanceMember ? 1 : 0).compareTo(other._isInstanceMember ? 1 : 0)
        case var value when value != 0) {
      return value;
    }
    if (_category.index.compareTo(other._category.index) case var value
        when value != 0) {
      return value;
    }
    return _name.compareTo(other._name);
  }

  static MemberCategory _computeCategory(Element element) => switch (element) {
    ConstructorElement() => MemberCategory.constructor,
    PropertyAccessorElement() => MemberCategory.propertyAccessor,
    TopLevelFunctionElement() => MemberCategory.topLevelFunctionOrMethod,
    MethodElement() => MemberCategory.topLevelFunctionOrMethod,
    InterfaceElement() => MemberCategory.interface,
    ExtensionElement() => MemberCategory.extension,
    TypeAliasElement() => MemberCategory.typeAlias,
    dynamic(:var runtimeType) => throw UnimplementedError(
      'Unexpected element: $runtimeType',
    ),
  };

  static bool _computeIsInstanceMember(Element element) =>
      element.enclosingElement is InstanceElement &&
      switch (element) {
        ExecutableElement(:var isStatic) => !isStatic,
        dynamic(:var runtimeType) => throw UnimplementedError(
          'Unexpected element: $runtimeType',
        ),
      };
}

/// A node to be printed to the output.
class Node<ChildSortKey extends Comparable<ChildSortKey>> {
  /// A list of objects which, when their string representations are
  /// concatenated, is the text that should be displayed on the first line of
  /// the node.
  ///
  /// The reason this is a list rather than a single string is to allow elements
  /// of the list to be [UniqueName] objects, which may acquire a disambiguation
  /// suffix at a later time.
  final text = <Object?>[];

  /// A list of child nodes, paired with a sort key indicating the order in
  /// which they should be output.
  final childNodes = <(ChildSortKey, Node)>[];

  /// Outputs [childNodes], prepending [prefix] to every line.
  void printChildren(StringSink sink, {required String prefix}) {
    _printNodes(sink, childNodes, prefix: prefix);
  }
}

/// Object that will have a unique string representation within the context of a
/// given [UniqueNamer] instance.
///
/// If two or more [UniqueName] objects are constructed with reference to the
/// same [UniqueNamer], and they have the same [_nameHint], then all such
/// objects' [toString] methods will append a unique suffix of the form
/// `@INTEGER`, so that the resulting strings are unique.
class UniqueName {
  /// The name that will be returned by [toString] if no disambiguation is
  /// needed.
  final String _nameHint;

  /// If not `Null`, the integer that [toString] will use to disambiguate this
  /// [UniqueName] from other ones with the same [_nameHint].
  int? _disambiguator;

  UniqueName(UniqueNamer uniqueNamer, this._nameHint) {
    // The uniqueness guarantee depends on `_nameHint` not containing an `@`.
    assert(!_nameHint.contains('@'));
    var conflicts = uniqueNamer._conflicts[_nameHint] ??= [];
    if (conflicts.length == 1) {
      conflicts[0]._disambiguator = 1;
    }
    conflicts.add(this);
    if (conflicts.length > 1) {
      _disambiguator = conflicts.length;
    }
  }

  @override
  String toString() => [
    _nameHint,
    if (_disambiguator case var disambiguator?) '@$disambiguator',
  ].join();
}

/// Manager of unique names for elements.
class UniqueNamer {
  final _names = <Element, UniqueName>{};
  final _conflicts = <String, List<UniqueName>>{};

  /// Returns a [UniqueName] object whose [toString] method will produce a
  /// unique name for [element].
  UniqueName name(Element element) =>
      _names[element] ??= UniqueName(this, element.apiName);
}

/// URI categorization used by [UriSortKey].
enum UriCategory { inPackage, notInPackage }

/// Sort key used to sort libraries in the output.
///
/// Libraries in the specified package will be output first (sorted by URI),
/// followed by libraries not in the package.
class UriSortKey implements Comparable<UriSortKey> {
  final UriCategory _category;
  final String _uriString;

  UriSortKey(Uri uri, String pkgName)
    : _category = uri.isIn(pkgName)
          ? UriCategory.inPackage
          : UriCategory.notInPackage,
      _uriString = uri.toString();

  @override
  int compareTo(UriSortKey other) {
    if (_category.index.compareTo(other._category.index) case var value
        when value != 0) {
      return value;
    }
    return _uriString.compareTo(other._uriString);
  }
}

extension on Iterable<Iterable<Object?>> {
  /// Forms a list containing [prefix], followed by the elements of `this`
  /// (separated by [separator]), followed by [suffix].
  List<Object?> separate({
    String separator = ', ',
    String prefix = '',
    String suffix = '',
  }) {
    var result = <Object?>[prefix];
    var first = true;
    for (var item in this) {
      if (first) {
        first = false;
      } else {
        result.add(separator);
      }
      result.addAll(item);
    }
    result.add(suffix);
    return result;
  }
}

extension on Element {
  /// Returns the appropriate name for describing the element in `api.txt`.
  ///
  /// The name is the same as [name], but with `=` appended for setters.
  String get apiName {
    var apiName = name!;
    if (this is SetterElement) {
      apiName += '=';
    }
    return apiName;
  }

  bool isInPublicApiOf(String packageName) {
    if (this case PropertyAccessorElement(
      isOriginVariable: true,
      :var variable,
    ) when variable.isInPublicApiOf(packageName)) {
      return true;
    }
    if (packageName == 'analyzer') {
      // Any element annotated with `@analyzerPublicApi` is considered to be
      // part of the public API of the analyzer package.
      if (metadata.annotations.any(_isPublicApiAnnotation)) {
        return true;
      }
    }
    if (name case var name? when !name.isPublic) return false;
    if (library!.uri.isInPublicLibOf(packageName)) return true;
    return false;
  }

  bool _isPublicApiAnnotation(ElementAnnotation annotation) {
    if (annotation.computeConstantValue() case DartObject(
      type: InterfaceType(element: InterfaceElement(name: 'AnalyzerPublicApi')),
    )) {
      return true;
    } else {
      return false;
    }
  }
}

extension on FormalParameterElement {
  bool get isDeprecated {
    // TODO(paulberry): add this to the analyzer public API
    return metadata.hasDeprecated;
  }
}

extension on String {
  bool get isPublic => !startsWith('_');
}

extension on Uri {
  bool isIn(String packageName) =>
      scheme == 'package' &&
      pathSegments.isNotEmpty &&
      pathSegments[0] == packageName;

  bool isInPublicLibOf(String packageName) =>
      scheme == 'package' &&
      pathSegments.length > 1 &&
      pathSegments[0] == packageName &&
      pathSegments[1] != 'src';
}
